在 ASP.NET Core Web API 中實現可選更新功能
TLDR
- 在 RESTful
PATCH操作中,透過自定義OptionalValue<T>結構,可精確區分「不更新該欄位」與「將欄位更新為 null」。 - 使用
JsonConverter處理[FromBody]請求,簡化 JSON 序列化格式。 - 使用
ModelBinder處理[FromForm]請求,解決表單資料繫結問題。 - 透過實作
IModelValidator,確保 Data Annotation 驗證僅在欄位有值時觸發。 - 透過
ISchemaFilter與IOperationFilter調整 Swagger 文件,使其正確呈現 API 規格。
在 ASP.NET Core Web API 中實現 PATCH 功能時,常面臨無法區分「客戶端未傳遞欄位(不更新)」與「客戶端傳遞 null(更新為 null)」的難題。特別是對於 struct 型別(如 DateTime 或 int),無法直接透過 null 來判斷。
解決此問題的核心思路是引入一個「註記欄位」來判斷是否需要更新。以下方案透過封裝 OptionalValue<T> 結構,並結合 ASP.NET Core 的擴充機制來達成。
可選屬性型別
什麼情況下會遇到這個問題:當 API 需要支援部分更新(Partial Update),且後端無法區分欄位是「未提供」還是「被設為 null」時。
我們建立一個 OptionalValue<T> 結構,利用 HasValue 屬性標記該欄位是否被傳遞。
public readonly record struct OptionalValue<T> {
private readonly T value;
public OptionalValue(T value) {
HasValue = true;
this.value = value;
}
public static OptionalValue<T> Empty() => new();
[ValidateNever]
public bool HasValue { get; }
[ValidateNever]
public T Value {
get {
if (!HasValue) {
throw new InvalidOperationException("OptionalValue object must have a value.");
}
return value;
}
}
public static implicit operator OptionalValue<T>(T value) {
return new OptionalValue<T>(value);
}
public static explicit operator T(OptionalValue<T> value) {
return value.Value;
}
}FromBody 的 JsonConverter
什麼情況下會遇到這個問題:當 API 使用 [FromBody] 接收 JSON 請求,且希望 JSON 結構保持簡潔,而非包含 hasValue 等額外屬性時。
透過自定義 JsonConverter 與 JsonConverterFactory,可以將 JSON 序列化結果簡化為直接對應屬性值。
public class OptionalValueConverter<T> : JsonConverter<OptionalValue<T>> {
public override OptionalValue<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
if (reader.TokenType == JsonTokenType.None) {
return OptionalValue<T>.Empty();
} else {
T? value = JsonSerializer.Deserialize<T>(ref reader, options);
return new OptionalValue<T>(value!);
}
}
public override void Write(Utf8JsonWriter writer, OptionalValue<T> value, JsonSerializerOptions options) {
if (value.HasValue) {
JsonSerializer.Serialize(writer, value.Value, options);
}
}
}FromForm 的 ModelBinder
什麼情況下會遇到這個問題:當 API 使用 [FromForm] 接收表單資料,且需要處理複雜的 Model 繫結時。
實作 IModelBinder 與 IModelBinderProvider,讓 ASP.NET Core 能自動將表單欄位映射至 OptionalValue<T>。
public class OptionalValueModelBinder<T> : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None) {
bindingContext.Result = ModelBindingResult.Success(OptionalValue<T>.Empty());
return Task.CompletedTask;
}
// 簡化處理邏輯,將字串轉換為目標型別
string? valueStr = valueProviderResult.FirstValue;
Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
object? convertedValue = Convert.ChangeType(valueStr, targetType);
bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T>((T)convertedValue!));
return Task.CompletedTask;
}
}處理資料驗證
什麼情況下會遇到這個問題:當 DTO 屬性上標註了 [Required] 或 [Range] 等驗證屬性,但希望在該欄位未被傳遞時跳過驗證。
透過實作 IModelValidator,我們可以確保驗證邏輯僅在 HasValue 為 true 時執行。
public class OptionalValueValidator<T> : IModelValidator {
private readonly ValidatorItem validatorItem;
public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem;
public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
if (context.Model is OptionalValue<T> optionalValue && optionalValue.HasValue) {
// 執行實際的驗證邏輯
// ...
}
return Enumerable.Empty<ModelValidationResult>();
}
}處理 Swagger Schema
什麼情況下會遇到這個問題:當上述客製化邏輯導致 Swagger 文件產出的 Schema 結構不符合預期,造成 API 文件難以閱讀時。
使用 ISchemaFilter 與 IOperationFilter 修改 swagger.json 的產出,隱藏 OptionalValue 內部的 HasValue 屬性,讓前端開發者看到乾淨的欄位定義。
異動歷程
- 2024-10-21 初版文件建立。
- 2026-05-17 補上 GitHub 範例專案連結。